<?php

/* 
 * Copyright 2010 Anton Muraviev a.k.a kuromaisu
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * 
 */

// =============================================================================
// Section: Core
// =============================================================================

// -----------------------------------------------------------------------------
// Constants: Essential constants
// -----------------------------------------------------------------------------
//
// * EXT		-- PHP scripts extension
// * CORE_DIR	-- path to core directory (incl. trailing slash)
// * BASE_DIR	-- path to root directory (incl. trailing slash)
//
if(!defined('EXT')) define('EXT', '.'.pathinfo(__FILE__, PATHINFO_EXTENSION));
if(!defined('CORE_DIR')) define('CORE_DIR', pathinfo(__FILE__, PATHINFO_DIRNAME) . '/');
if(!defined('BASE_DIR')) define('BASE_DIR', aero_real_path('.') . '/');

// -----------------------------------------------------------------------------
// Constants: Class types
// -----------------------------------------------------------------------------
// 
// Class type determines how objects are being created when the file is loaded.
// 
// * `AS_CLASS`		-- new instance is created for every `Aero::load()` call;
// * `AS_SINGLETON`	-- one instance of the class is being used at all times;
// * `AS_STATIC`		-- no instance is created.
// 
define(AS_CLASS, 0);
define(AS_SINGLETON, 1);
define(AS_STATIC, 2);

/* -----------------------------------------------------------------------------
 * Class: Aero
 * -----------------------------------------------------------------------------
 *
 * Core framework class.
 *
 * This is a static class (i.e. all its methods should be called as static) and 
 * should not be instantiated.
 *
 */
final class Aero
{
	private static $loaded_paths = array();
	private static $options = array();
	private static $contexts = array();
	
	private $previous_context;
	private static $current_context = null;
	
	private static $current_info = array();
	private static $current_path = '';
	
	private static $stack_level = 0;
	private static $current_info_stack = array();
	private static $current_path_stack = array();
	private static $current_context_stack = array();
	
	/*
	 * Creates a previously registered context. 
	 * 
	 * All subsequent calls `load()`, `run()`, etc will be executed within that context
	 * until its destroyed. 
	 * 
	 * __Context must not be retained any longer than needed!__
	 * 
	 * Also see `__destruct()`.
	 * 
	 */
	public function __construct($context)
	{
		if (!isset(self::$contexts[$context]))
		{
			throw new AeroException('Attempted to use undefined context "'.$name.'".');
		}
		
		$this->previous_context = self::$current_context;
		self::$current_context = $context;
	}
	
	/*
	 * Destroys current context and restores the previous one.
	 */
	public function __destruct()
	{
		self::$current_context = $this->previous_context;
	}
	
	/*
	 * Adds new context.
	 * 
	 * FIXME: Should accept full directory or file path
	 */
	public static function register($name, $directory = null)
	{
		if (!is_null($directory))
		{
			$context_dir = BASE_DIR . aero_trim_path($directory) . '/';
		}
		else if (!empty(self::$current_path))
		{
			$components = explode('/',self::$current_path);
			array_pop($components);
			$context_dir = implode('/',$components) . '/';
		}
		else
		{
			throw new AeroException('Could not detect directory for context "'.$name.'" automatically.');
		}
		
		self::$contexts[$name] = $context_dir;
		self::$current_context = $name;
	}
	
	/*
	 * Method: define
	 * 
	 * Implicitly states which class has been defined in the file.
	 * 
	 * Each file that wants to be loaded and managed by Aero needs to explicitly
	 * specify which file it <defines>. Only one defenition per file is supported.
	 * 
	 * ~~~
	 * <?php // file begins
	 * 	Aero::define('SomeClass', AS_SINGLETON);
	 * 	
	 * 	class SomeClass {
	 * 		// ...implementation...
	 * } ?>
	 * ~~~
	 * 
	 * Parameters:
	 * 	string	-	class name;
	 * 	string	-	(optional) either *object*, *static* or *singleton*.
	 */
	public static function define($class_name, $type)
	{
		switch ($type)
		{
			default:
				trigger_error('Unknown class invocation type: "'.$type.'"', E_USER_NOTICE);
			case AS_CLASS:
				$instantiate = true;
				$singleton = false;
				break;
			case AS_SINGLETON:
				$instantiate = true;
				$singleton = true;
				break;
			case AS_STATIC:
				$instantiate = false;
				$singleton = false;
				break;
		}
		
		self::$current_info_stack[self::$stack_level] = array(
			'class_name' => $class_name,
			'instantiate' => !empty($instantiate),
			'singleton' => !empty($instantiate) && !empty($singleton),
		);
	}

	/*
	 * Method: load
	 *
	 * Looks for a script inside current context direcotry or CORE_DIR and loads it.
	 * 
	 * By default Aero only includes the script. Use `Aero::define()` in your 
	 * script to tell Aero how to instantiate it.
	 *
	 * Parameters:
	 *	string	-	path to the file to be loaded (without extension);
	 *	misc	-	(optional) parameter to pass into the new obejct.
	 *
	 * Returns:
	 *	object	-	if 'singleton' or 'object' has been loaded;
	 *	void	-	in other cases.
	 */
	public static function load($path, $parameters = array())
	{
		$path = aero_trim_path($path);
		
		if (isset(self::$loaded_paths[self::$current_context][$path]['instance']))
		{
			return self::$loaded_paths[self::$current_context][$path]['instance'];
		}
		
		Aero::import($path);

		$class_name = isset(self::$current_info['class_name']) ? self::$current_info['class_name'] : '';
		$instantiate = !empty(self::$current_info['instantiate']);
		$singleton = !empty(self::$current_info['singleton']);
		
		// Static classes are not instantiated
		if (empty($class_name) || !$instantiate)
		{
			return;
		}
		else if ($singleton && isset(self::$loaded_paths[self::$current_context][$path]['instance']))
		{
			return self::$loaded_paths[self::$current_context][$path]['instance'];
		}
		else if (!empty($class_name) && class_exists($class_name))
		{
			if ($singleton)
			{
				return self::$loaded_paths[self::$current_context][$path]['instance'] = new $class_name();
			}
			else
			{
				return new $class_name($parameters);
			}
		}
	}
	
	/*
	 * Method: import
	 * 
	 * Imports the script.
	 * 
	 * Internally it calculates <basePathFor> the file, requires it and collects
	 * any meta information about the file, i.e. class name and type.
	 * 
	 * Parameters:
	 *	string	-	path to the file to be imported (without extension).
	 */
	public static function import($path)
	{
		$path = aero_trim_path($path);
		
		// Check if it has been imported already
		if (isset(self::$loaded_paths[self::$current_context][$path]))
		{
			self::$current_info = self::$loaded_paths[self::$current_context][$path];
			return;
		}
		
		// Save state
		self::_beforeImport();
		self::$current_path = Aero::basePathFor($path) . $path;
		
		// We need to capture any exceptions to restore the stack
		try {
			require self::$current_path . EXT;
		} catch (Exception $e) {
			self::_afterImport();
			throw $e;
		}
		
		// Restore state
		self::_afterImport();
		
		self::$loaded_paths[self::$current_context][$path] = self::$current_info;
	}
	
	/*
	 * Method: run
	 *
	 * Executes the script; if needed, buffers its output and returns as a 
	 * string.
	 *
	 * Parameters:
	 *	string	-	path to the script to execute (without extension);
	 *	array	-	(optional) an array of paramters to pass into the script;
	 *	bool	-	(optional) whether to buffer the output and send it to stdout.
	 *
	 * Returns:
	 *	string	-	if output has been buffered;
	 *	void	-	otherwise.
	 */
	public static function run($path, $parameters = array(), $return = false)
	{
		$path = aero_trim_path($path);
		
		ob_start();
		extract($parameters, EXTR_REFS);
		
		// Save state
		self::_beforeImport();
		self::$current_path = Aero::basePathFor($path) . $path;
		
		// We need to capture any exceptions to restore the stack
		try {
			require self::$current_path . EXT;
		} catch (Exception $e) {
			self::_afterImport();
			throw $e;
		}
		
		// Restore state
		self::_afterImport();
		
		if ($return === true)
		{
			return ob_get_clean();
		}
		else
		{
			ob_end_flush();
		}
	}
	
	/*
	 * Returns current context directory
	 * 
	 */
	public static function basePath()
	{
		return !isset(self::$contexts[self::$current_context]) ? CORE_DIR : self::$contexts[self::$current_context];
	}
	
	/*
	 * Method: basePathFor
	 * 
	 * Returns correct base path for specified relative path.
	 * 
	 * Parameters:
	 * 	string	-	relative path to script without extension.
	 * 
	 * Returns:
	 * 	string	-	base path
	 * 
	 * Throws:
	 * 	AeroException	-	if no file has been found.
	 */
	public static function basePathFor($path)
	{
		if (isset(self::$contexts[self::$current_context]))
		{
			$context_dir = self::$contexts[self::$current_context];
		}
		
		// Determine base directory
		if (isset($context_dir)
			&& self::$current_path !== ($context_dir . $path)
			&& file_exists($context_dir . $path . EXT))
		{
			return $context_dir;
		}
		else if (file_exists(CORE_DIR . $path . EXT))
		{
			return CORE_DIR;
		}
		else
		{
			throw new AeroException('Could not find <kbd>'.$path.'</kbd>.');
		}
	}
	
	/*
	 * Method: _beforeImport
	 * 
	 * Is executed before <imports>, <loads> or <runs> each script file.
	 */
	protected static function _beforeImport()
	{
		self::$stack_level++;
		array_push(self::$current_path_stack, self::$current_path);
		array_push(self::$current_context_stack, self::$current_context);
	}
	
	/*
	 * Method: _afterImport
	 * 
	 * Is executed after <imports>, <loads> or <runs> each script file.
	 */
	protected static function _afterImport()
	{
		if (isset(self::$current_info_stack[self::$stack_level]))
		{
			self::$current_info = self::$current_info_stack[self::$stack_level];
			unset(self::$current_info_stack[self::$stack_level]);
		}
		else
		{
			self::$current_info = array();
		}
		
		self::$stack_level--;
		self::$current_path = array_pop(self::$current_path_stack);
		self::$current_context = array_pop(self::$current_context_stack);
	}
	
	/*
	 * Method: isImported
	 *
	 * Checks if the file has been <import>'ed or <load>'ed already.
	 *
	 * Parameters:
	 *	string	-	file path to check (without extension).
	 *
	 * Returns:
	 *	true	-	if file has been imported;
	 * 	false	-	otherwise.
	 */
	public static function isImported($path)
	{
		$path = aero_trim_path($path);

		// Check, if included
		if (isset(self::$loaded_paths[self::$current_context][$path]))
		{
			return true;
		}

		return false;
	}
	
	/*
	 * Method: isInitialized
	 *
	 * Checks if the library has been initialized. 
	 * 
	 * _Only_singletons_are_initialized_by_Aero_. The script returns
	 * false for all other classes.
	 *
	 * Parameters:
	 *	string	-	file path to check (without extension).
	 *
	 * Returns:
	 *	true	-	if library has been <load>'ed *and* it <define>'d a *singleton*;
	 *	false 	-	otherwise.
	 */
	public static function isInitialized($path)
	{
		$path = aero_trim_path($path);

		// Check, if included
		if (isset(self::$loaded_paths[self::$current_context][$path]['instance']))
		{
			return true;
		}

		return false;
	}
	
	/*
	 * Method: classNameOf
	 *
	 * Returns correct class name for the library at the specified path
	 *
	 * Parameters:
	 *	string	-	file path (without extension).
	 *
	 * Returns:
	 * 	string	-	class name;
	 * 	false	-	if no class name has been defined in that file.
	 *
	 * Throws:
	 *	ErrorException	-	if file has not been loaded.
	 */
	public static function classNameOf($path)
	{
		$path = aero_trim_path($path);
		
		// Check, if included
		if (!isset(self::$loaded_paths[self::$current_context][$path]))
		{
			throw new AeroException('<code>Aero::classNameOf("'.$path .'")</code> has failed. File not loaded.');
		}
		
		return isset(self::$loaded_paths[self::$current_context][$path]['class_name']) ?
			self::$loaded_paths[self::$current_context][$path]['class_name'] :
			false;
	}
	
	public function pathOf($class)
	// 
	// Searches through loaded classes and returns its path, if successful.
	// 
	// Parameters:
	// * `string` -- name of the class.
	// 
	// Returns:
	// * `string` -- absolute path;
	// * `false` -- if no class found.
	{
		static $cache;
		$class = trim($class);
		
		if (isset($cache[$class]))
		{
			return $cache[$class];
		}
		
		$loaded = self::$loaded_paths[self::$current_context];
		
		foreach ($loaded as $path => $data)
		{
			if (isset($data['class_name']) && $data['class_name'] === trim($class))
			{
				return $cache[$class] = $path;
			}
		}
		
		return $cache[$class] = false;
	}
	
	/*
	 * Method: option
	 *
	 * Returns a configuration option value or $default, if no such option has 
	 * been found.
	 * 
	 * Options can be grouped using namespaces. For instance "namespace.option_1" and
	 * "namespace.option_2" are two valid option names. But one can also simply use
	 * "namespace" to retrieve both options as an array.
	 * 
	 * If both option value and default value are arrays, this method will merge
	 * them. This way you can query multiple options and if one of them does not exist
	 * a default value will be returned instead.
	 *
	 * Parameters:
	 *  string	-	option name;
	 *  misc	-	(optional) default value.
	 *
	 * Returns:
	 *	misc	-	option value, if such option exists, or $default.
	 * 
	 * See also:
	 * 	<setOption>, <unsetOption>
	 */
	public static function option($name, $default = null)
	{
		return aero_get_nested_array_value(self::$options, $name, $default);
	}
	
	/*
	 * Method: setOption
	 *
	 * Sets a configuration option.
	 *
	 * Parameters:
	 *  string	-	option name;
	 *	misc	-	option value.
	 * 
	 * See also:
	 * 	<option>, <unsetOption>
	 */
	public static function setOption($name, $value = null)
	{
		return aero_set_nested_array_value(self::$options, $name, $value);
	}
	
	/*
	 * Method: unsetOption
	 *
	 * Unsets a configuration option.
	 * 
	 * Parameters:
	 *  string	-	option name
	 * 
	 * See also:
	 * 	<option>, <setOption>
	 */
	public static function unsetOption($name)
	{
		return aero_unset_nested_array_value(self::$options,$name);
	}
}

/* -----------------------------------------------------------------------------
 * Class: AeroException
 * -----------------------------------------------------------------------------
 */
class AeroException extends Exception {}


/*
 * Function: aero_trim_path
 * 
 * Trims spaces and slashes. Replaces all backward slashed with
 * forward ones.
 * 
 * Parameters:
 * 	string	-	original path.
 * 
 * Returns:
 * 	string	-	trimmed path.
 */
function aero_trim_path($path)
{
	return trim(str_replace('\\','/',$path), " /");
}

/*
 * Function: aero_real_path
 * 
 * Same as realpath(), but always uses forward slash as delimeter 
 * and trims the ending slash.
 * 
 * TODO: Confirm http://www.php.net/manual/en/function.realpath.php#98372
 * 
 * Parameters:
 * 	string	-	original path.
 * 
 * Returns:
 * 	string	-	real path.
 */
function aero_real_path($path)
{
	$realpath = realpath($path);
	
	if (is_string($realpath))
	{
		$realpath = rtrim(str_replace('\\','/',$realpath), '/');
	}
	
	return $realpath;
}

/*
 * Function: aero_get_nested_array_value
 * 
 * Retrives value from nested array using a point-separated path of keys.
 * 
 */
function aero_get_nested_array_value(&$array, $path, $default = null)
{
	$sub_array =& $array;
	$keys = explode('.',$path);

	foreach ($keys as $key)
	{
		if (!isset($sub_array[$key]))
		{
			return $default;
		}
		
		$sub_array =& $sub_array[$key];
	}
	
	if (is_array($sub_array) && is_array($default))
	{
		return array_merge($default, $sub_array);
	}
	
	return $sub_array;
}

/*
 * Function: aero_set_nested_array_value
 * 
 * Sets value in a nested array using a point-separated path of keys.
 * 
 */
function aero_set_nested_array_value(&$array, $path, $value)
{
	if (is_array($path))
	{
		foreach ($path as $_path => $_value)
		{
			aero_set_nested_array_value($array, $_path, $_value);
		}
		
		return;
	}
	
	$sub_array =& $array;
	$keys = explode('.', $path);
	
	foreach ($keys as $key)
	{
		if (!isset($sub_array[$key]))
		{
			$sub_array[$key] = array();
		}
		
		$sub_array =& $sub_array[$key];
	}
	
	$sub_array = $value;
}

/*
 * Function: aero_unset_nested_array_value
 * 
 * Unsets value in a nested array using a point-separated path of keys.
 */
function aero_unset_nested_array_value(&$array, $path)
{
	if (is_array($path))
	{
		foreach ($path as $_path)
		{
			aero_unset_nested_array_value($array,$_path);
		}
		
		return;
	}
	
	$keys = explode('.',$path);
	$last = array_pop($keys);
	$sub_array =& $array;
	
	foreach ($keys as $key)
	{
		if (!isset($sub_array[$key]))
		{
			return;
		}
		
		$sub_array =& $sub_array[$key];
	}
	
	unset($sub_array[$last]);
}
